﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace GerbilLib
{
    /// <summary>
    /// Provides methods to retrieve media files and directories for further processing.
    /// </summary>
    public class MediaCrawler
    {
        /// <summary>
        /// Regular expression for tv series episode file naming format
        /// </summary>
        private static Regex regexSeriesEpisodeFormat = new Regex(@".*?(?<Episode>[\[Ss]*\d+[\]\. _]*[\[EeXx]+\d+[\]]?).*");
        /// <summary>
        /// Regular expression for tv series season folder naming format
        /// </summary>
        private static Regex regexSeriesSeasonFormat = new Regex(@"Season[\s\._\[\]\(\)]*?(\d+)|Series[\s\._\[\]\(\)]*?(\d+)");
        /// <summary>
        /// Object used for thread-safe locking operations
        /// </summary>
        private static object lockObject = new object();

        /// <summary>
        /// Takes a list of paths and processes each path in a separate parallel task to
        /// speed up processing times. The MediaItem list will be populated with all the
        /// media items when all tasks finished processing.
        /// Note that the MediaItems list will not be cleared and only MediaItems that
        /// are not already in it will be added. However, no items are removed from the
        /// list.
        /// </summary>
        /// <param name="paths">List of paths to process</param>
        /// <param name="items">List of MediaItems to be populated</param>
        public static async void GetMediaItemsFromPath(List<MediaDirectory> paths, List<MediaDirectory> excludedPaths, AsyncBindingList<MediaItem> items, HashSet<string> extensions, HashSet<string> ignoredDirs)
        {
            List<Task> tasks = new List<Task>();
            foreach (MediaDirectory path in paths)
            {
                MediaDirectory localPath = path;
                tasks.Add(TaskManager.Run(string.Format("Update Media Items From Path {0}", path.Path), () => ProcessMediaDirectory(localPath, excludedPaths, items, extensions, ignoredDirs)));
            }
            await TaskManager.WhenAll(tasks);
        }

        /// <summary>
        /// Processes a directory and all its subdirectories looking for valid media files.
        /// Only one media item will be created for a directory which contains multiple media
        /// files.
        /// </summary>
        /// <param name="path">Path from which to start processing</param>
        /// <param name="excludedPaths">Paths which will be skipped when processing</param>
        /// <param name="items">List of MediaItems to be populated</param>
        private static void ProcessMediaDirectory(MediaDirectory path, List<MediaDirectory> excludedPaths, AsyncBindingList<MediaItem> items, HashSet<string> extensions, HashSet<string> ignoredDirs)
        {
            int newItems = 0;
            MediaItemEqualityComparer comparer = new MediaItemEqualityComparer();
            System.Diagnostics.Stopwatch stopwatch = new System.Diagnostics.Stopwatch();
            stopwatch.Start();

            DirectoryInfo di = new DirectoryInfo(path.Path);
            if (!di.Exists)
            {
                Logger.LogMessage(string.Format("MediaCrawler: The path {0} could not be found and will be skipped!", path), LoggingLevel.Warning);
                return;
            }
            IEnumerable<string> fileNames = GetAllMediaFiles(path.Path, extensions);

            //Parallel.ForEach(fileNames, filename =>
            foreach(var filename in fileNames)
            {
                string parentDir = GetDirectoryName(filename);
                if (!IsExcludedDirectory(parentDir, excludedPaths) && !IsIgnoredDirectory(parentDir, ignoredDirs))
                {
                    string pathName = Path.GetFileName(parentDir);
                    MediaItem item = null;
                    if (path.MediaType == MediaType.Series && IsFollowingEpisodeNamingConvention(filename))
                    {
                        string seriesName = IsFollowingSeasonNamingConvention(filename) ? Path.GetFileName(GetDirectoryName(parentDir)) : Path.GetFileName(parentDir);
                        item = new MediaItem(path: IsFollowingSeasonNamingConvention(filename) ? GetDirectoryName(parentDir) : parentDir, type: MediaType.Series, title: seriesName);
                    }
                    // Regular movie title
                    else if (path.MediaType != MediaType.Series)
                        item = new MediaItem(path: parentDir, title: pathName, fileExtension: Path.GetExtension(filename));
                    // This is for files that are in Series folders but don't follow naming convention,
                    // so we don't end up with Season folders getting added to the list.
                    else continue;

                    lock (lockObject)
                    {
                        if (!items.Contains(item, comparer))
                        {
                            item.HasCoverImage = item.HasLocalCoverImage();
                            items.Add(item, synchronousAdd: true);
                            newItems++;
                            Logger.LogMessage(string.Format("MediaCrawler: Added {0} {1} to media list.", item.MediaType, parentDir));
                        }
                    }
                }
                else
                    Logger.LogMessage(string.Format("MediaCrawler: Skipped {0} because it is in the excluded or ignored directories list.", parentDir));
            }//);

            stopwatch.Stop();
            Logger.LogMessage(string.Format("MediaCrawler: ...done processing directory {0} and its subdirectories in {1:F} seconds! Added {2} new items!", path, stopwatch.ElapsedMilliseconds / 1000.0,newItems));
        }

        /// <summary>
        /// The implementation of Path.GetDirectoryName has a limitation regarding the maximum
        /// length of the path and will throw a PathTooLongException if that limit is exceeded.
        /// During testing it was encountered that there could be valid paths which exceed the
        /// method limit, therefore a manual implementation is provided by this method which
        /// will return the correct reply for those cases.
        /// </summary>
        /// <param name="path">The path of a file or directory</param>
        /// <returns>The directory information for the specified path string</returns>
        private static string GetDirectoryName(string path)
        {
            try
            {
                // Try the easy way first
                return Path.GetDirectoryName(path);
            }
            catch(PathTooLongException)
            {
                // Manually retrieve the correct response. We're omitting the check for root
                // directories such as C:\ since the reason we are here was that the path was
                // too long in the first place. C:\path\cool\awesome.mkv
                char[] separators = new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar };
                return path.Substring(0, path.LastIndexOfAny(separators));
            }
        }

        /// <summary>
        /// Gets a collection of all media files matching any of the provided file extensions
        /// underneath the given path and its subdirectories.
        /// </summary>
        /// <param name="path">The path to search for media files in.</param>
        /// <param name="extensions">The file extensions for valid media files.</param>
        /// <returns>Collection of pathnames to all files found.</returns>
        private static IEnumerable<string> GetAllMediaFiles(string path, HashSet<string> extensions)
        {
            IEnumerable<string> fileNames = null;

            // Try fast method first and only switch to slower method if this one fails
            try
            {
                fileNames = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories).Where(f => extensions.Contains(Path.GetExtension(f)));
                Logger.LogMessage(string.Format("MediaCrawler: I found {0} files in directory {1} and its subdirectories. Starting processing...", fileNames.Count(), path));
                return fileNames;
            }
            catch
            {
                Logger.LogMessage(string.Format("MediaCrawler couldn't enumerate all files in path {0}. Trying alternate method...", path));
            }
            // If faster method didn't work try stack-based manual directory traversion
            try
            {
                fileNames = TraverseTree(path, extensions);
                Logger.LogMessage(string.Format("MediaCrawler: I found {0} files in directory {1} and its subdirectories. Starting processing...", fileNames.Count(), path));
            }
            catch (Exception ex)
            {
                Logger.LogMessage(string.Format("MediaCrawler: There was an error while traversing directories: {0}", ex.Message), LoggingLevel.Error);
            }

            return fileNames;
        }


        /// <summary>
        /// Determines whether the specified file name is following season naming convention.
        /// </summary>
        /// <param name="fileName">Name of the file.</param>
        /// <returns>
        ///   <c>true</c> if the specified file name is following season naming convention; otherwise, <c>false</c>.
        /// </returns>
        public static bool IsFollowingSeasonNamingConvention(string fileName)
        {
            return regexSeriesSeasonFormat.IsMatch(fileName);
        }

        /// <summary>
        /// Determines whether the specified file name is following the episode naming convention.
        /// </summary>
        /// <param name="fileName">Name of the file.</param>
        /// <returns>
        ///   <c>true</c> if the specified file name is following the episode naming convention; otherwise, <c>false</c>.
        /// </returns>
        public static bool IsFollowingEpisodeNamingConvention(string fileName)
        {
            return regexSeriesEpisodeFormat.IsMatch(fileName);
        }

        /// <summary>
        /// Determines whether the specified path is part of the excluded media directories list or
        /// one of its subdirectories.
        /// </summary>
        /// <param name="path">Path to be checked for exclusion.</param>
        /// <param name="excludedPaths">List of excluded directories.</param>
        /// <returns>True if the path is excluded, otherwise false.</returns>
        public static bool IsExcludedDirectory(string path, List<MediaDirectory> excludedPaths)
        {
            foreach (MediaDirectory excludedPath in excludedPaths)
                if (path.ToLower().StartsWith(excludedPath.Path.ToLower())) return true;

            return false;
        }

        /// <summary>
        /// Determines whether the specified path is part of the ignored directories list.
        /// </summary>
        /// <param name="path">The path to be checked for exclusion.</param>
        /// <param name="ignoredDirs">The ignored directories list.</param>
        /// <returns>
        ///   <c>true</c> if the specified path is an ignored directory; otherwise, <c>false</c>.
        /// </returns>
        public static bool IsIgnoredDirectory(string path, HashSet<string> ignoredDirs)
        {
            foreach (string dir in ignoredDirs)
                if (ignoredDirs.Contains(Path.GetFileName(path))) return true;

            return false;
        }

        /// <summary>
        /// Gets the list of all media items which don't have a cover image.
        /// </summary>
        /// <param name="items">List of media items to be checked for cover images.</param>
        /// <returns>List of media items which don't have a cover image.</returns>
        public static List<MediaItem> GetItemsWithoutCoverImages(AsyncBindingList<MediaItem> items)
        {
            Logger.LogMessage("Checking for media items without cover image...");
            List<MediaItem> subset = new List<MediaItem>();
            foreach (MediaItem item in items)
                if (!item.HasLocalCoverImage()) subset.Add(item);
            
            return subset;
        }

        /// <summary>
        /// Deletes the folder.jpg Cover Image from the specified item folders.
        /// </summary>
        /// <param name="items">Items for which cover images will be deleted.</param>
        public static void DeleteCoverImagesForItems(List<MediaItem> items)
        {
            foreach (MediaItem item in items)
                if (item.HasLocalCoverImage())
                    File.Delete(string.Format("{0}{1}", item.Path, MediaItem.DefaultCoverImageFilename));
        }

        /// <summary>
        /// Deletes the backdrop.jpg, backdrop1.jpg etc. Backdrop Images from the specified item folders.
        /// </summary>
        /// <param name="items">Items for which backdrop images will be deleted.</param>
        public static void DeleteBackdropImagesForItems(List<MediaItem> items)
        {
            foreach (MediaItem item in items)
                foreach(string filename in Directory.EnumerateFiles(item.Path, "backdrop*", SearchOption.TopDirectoryOnly))
                    File.Delete(filename);
        }

        /// <summary>
        /// Removes media items from the list which are currently unreachable.
        /// </summary>
        /// <param name="items">List of items to be checked for unreachable paths.</param>
        /// <returns>Number of items that were removed from the list.</returns>
        public static int RemoveMediaItemsWithUnreachablePath(AsyncBindingList<MediaItem> items)
        {
            Logger.LogMessage("Checking for media items which don't exist anymore...");

            List<MediaItem> removedItemsList = new List<MediaItem>();
            int inaccessibleItems = 0;
            for(int index = items.Count - 1; index > 0; index--)
            {
                MediaItem item = items[index];
                if (item.CanAccessPath() && !item.HasReachablePath())
                    removedItemsList.Add(item);
                else if (!item.CanAccessPath())
                {
                    Logger.LogMessage(string.Format("Unable to access media item {0}, leaving on list for now.", item.Path));
                    inaccessibleItems++;
                }
                else
                    // Update status of cover image in case it was updated externally
                    item.HasCoverImage = item.HasLocalCoverImage();
            }

            // Remove all items we found from actual list
            RemoveMediaItems(items, removedItemsList);

            Logger.LogMessage(string.Format("Removed {0} media items which did not exist anymore from list! There were {1} media items with an inaccessible path, they were not removed from the list!", removedItemsList.Count, inaccessibleItems));
            return removedItemsList.Count;
        }

        /// <summary>
        /// Removes media items from the list which are in excluded directories.
        /// </summary>
        /// <param name="items">List of items to be checked for items in excluded directories.</param>
        /// <param name="excludedPaths">List of excluded directories.</param>
        /// <returns>Number of items that were removed from the list.</returns>
        public static int RemoveMediaItemsWithExcludedPath(AsyncBindingList<MediaItem> items, List<MediaDirectory> excludedPaths)
        {
            Logger.LogMessage("Checking for media items which are in excluded directories...");

            List<MediaItem> removedItemsList = new List<MediaItem>();
            for (int index = items.Count - 1; index > 0; index--)
            {
                MediaItem item = items[index];
                if (IsExcludedDirectory(item.Path, excludedPaths))
                    removedItemsList.Add(item);
            }

            // Remove all items we found from actual list
            RemoveMediaItems(items, removedItemsList);

            Logger.LogMessage(string.Format("Removed {0} media items in excluded directories from list!", removedItemsList.Count));
            return removedItemsList.Count;
        }

        /// <summary>
        /// Removes media items from the list which are in ignored directories. The difference
        /// between ignored and excluded directories is that excluded directories handle absolute
        /// paths while ignored directories only look at the relative path.
        /// </summary>
        /// <param name="items">List of items to be checked for items in ignored directories.</param>
        /// <param name="ignoredDirs">List of ignored directories.</param>
        /// <returns>Number of items that were removed from the list.</returns>
        public static int RemoveMediaItemsInIgnoredDirectory(AsyncBindingList<MediaItem> items, HashSet<string> ignoredDirs)
        {
            Logger.LogMessage("Checking for media items which are in ignored directories...");

            List<MediaItem> removedItemsList = new List<MediaItem>();
            for (int index = items.Count - 1; index > 0; index--)
            {
                MediaItem item = items[index];
                if (IsIgnoredDirectory(item.Path, ignoredDirs))
                    removedItemsList.Add(item);
            }

            // Remove all items we found from actual list
            RemoveMediaItems(items, removedItemsList);

            Logger.LogMessage(string.Format("Removed {0} media items in ignored directories from list!", removedItemsList.Count));
            return removedItemsList.Count;
        }

        /// <summary>
        /// Removes the media items without media files from the list. This would typically happen because
        /// files were moved into a subdirectory or the list of file extensions was changed to exclude
        /// extensions which were previously allowed.
        /// </summary>
        /// <param name="items">The list of media items.</param>
        /// <param name="extensions">The list of allowed media file extensions.</param>
        /// <returns>Number of items that were removed from the list.</returns>
        public static int RemoveMediaItemsWithoutMediaFiles(AsyncBindingList<MediaItem> items, HashSet<string> extensions)
        {
            Logger.LogMessage("Checking for media items which don't have media files in them anymore...");

            List<MediaItem> removedItemsList = new List<MediaItem>();
            int skippedItems = 0;
            for (int index = items.Count - 1; index > 0; index--)
            {
                MediaItem item = items[index];
                if (item.ForeColor == System.Drawing.Color.Gray || !item.CanAccessPath())
                {
                    Logger.LogMessage(string.Format("Skipping media item {0} because it is not accessible.", item.Title));
                    skippedItems++;
                    continue;
                }
                
                IEnumerable<string> fileNames = null;
                try
                {
                    fileNames = Directory.EnumerateFiles(item.Path, "*", item.MediaType == MediaType.Series ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly).Where(f => extensions.Contains(Path.GetExtension(f)));
                }
                catch (Exception ex)
                {
                    Logger.LogMessage(string.Format("RemoveMediaItemsWithoutMediaFiles encountered an error: {0}", ex.Message));
                    continue;
                }

                if (fileNames != null && fileNames.Count() == 0)
                    removedItemsList.Add(item);
            }

            // Remove all items we found from actual list
            RemoveMediaItems(items, removedItemsList);

            Logger.LogMessage(string.Format("Removed {0} media items which did not have media files in it anymore! Skipped {1} media items which were not accessible!", removedItemsList.Count, skippedItems));
            return removedItemsList.Count;
        }

        /// <summary>
        /// Removes all given items in the list from the provided items collection. This is used
        /// by various clean-up methods that remove media items from the list.
        /// </summary>
        /// <param name="items">Collection of items.</param>
        /// <param name="removedItemsList">List of items to be removed from the collection.</param>
        private static void RemoveMediaItems(AsyncBindingList<MediaItem> items, List<MediaItem> removedItemsList)
        {
            foreach (MediaItem item in removedItemsList)
            {
                items.Remove(item);
                Logger.LogMessage(string.Format("Removed media item {0} from list.", item.Title));
            }
        }

        /// <summary>
        /// Returns a list of directory names that are below the provided root directory. Only directories
        /// which contain files that match certain file extensions will be returned. This works the same
        /// as Directory.EnumerateDirectories with the exception that it won't stop if it hits a directory
        /// with access denied permission error. TraverseTree will simply skip that directory and continue,
        /// providing a more robust implementation than Directory.EnumerateDirectories.
        /// </summary>
        /// <param name="root">The root directory from which to start.</param>
        /// <param name="extensions">File extensions that need to be matched.</param>
        /// <returns>List of directories and subdirectories with files matching the extension list.</returns>
        public static List<string> TraverseTree(string root, HashSet<string> extensions)
        {
            // Data structure to hold names of subfolders to be
            // examined for files.
            Stack<string> dirs = new Stack<string>(20);
            List<string> fileNames = new List<string>();

            if (!Directory.Exists(root))
            {
                throw new ArgumentException();
            }
            dirs.Push(root);

            while (dirs.Count > 0)
            {
                string currentDir = dirs.Pop();
                IEnumerable<string> subDirs;
                try
                {
                    //subDirs = Directory.GetDirectories(currentDir);
                    subDirs = Directory.EnumerateDirectories(currentDir);
                }
                // An UnauthorizedAccessException exception will be thrown if we do not have
                // discovery permission on a folder or file. It may or may not be acceptable 
                // to ignore the exception and continue enumerating the remaining files and 
                // folders. It is also possible (but unlikely) that a DirectoryNotFound exception 
                // will be raised. This will happen if currentDir has been deleted by
                // another application or thread after our call to Directory.Exists. The 
                // choice of which exceptions to catch depends entirely on the specific task 
                // you are intending to perform and also on how much you know with certainty 
                // about the systems on which this code will run.
                catch (UnauthorizedAccessException e)
                {
                    Logger.LogMessage(string.Format("Error while traversing directory tree: {0}", e.Message), LoggingLevel.Debug);
                    continue;
                }
                catch (System.IO.DirectoryNotFoundException e)
                {
                    Logger.LogMessage(string.Format("Error while traversing directory tree: {0}", e.Message), LoggingLevel.Debug);
                    continue;
                }

                IEnumerable<string> files = null;
                try
                {
                    //files = System.IO.Directory.GetFiles(currentDir);
                    files = Directory.EnumerateFiles(currentDir).Where(f => extensions.Contains(Path.GetExtension(f)));
                }
                catch (UnauthorizedAccessException e)
                {
                    Logger.LogMessage(string.Format("Error while traversing directory tree: {0}", e.Message), LoggingLevel.Debug);
                    continue;
                }
                catch (System.IO.DirectoryNotFoundException e)
                {
                    Logger.LogMessage(string.Format("Error while traversing directory tree: {0}", e.Message), LoggingLevel.Debug);
                    continue;
                }
                // Perform the required action on each file here.
                // Modify this block to perform your required task.
                fileNames.AddRange(files);

                // Push the subdirectories onto the stack for traversal.
                // This could also be done before handing the files.
                foreach (string str in subDirs)
                    dirs.Push(str);
            }

            return fileNames;
        }
    }

    /// <summary>
    /// A MediaDirectory consists of a path to a directory on the local or
    /// a remote filesystem as well as a MediaType enumeration value. This class
    /// is used by the Preferences to store included and excluded media directories.
    /// </summary>
    public class MediaDirectory : object
    {
        /// <summary>
        /// Gets or sets the path to the media directory.
        /// </summary>
        /// <value>
        /// The path.
        /// </value>
        public string Path { get; set; }
        /// <summary>
        /// Gets or sets the type of the media in the directory.
        /// </summary>
        /// <value>
        /// The type of the media.
        /// </value>
        public MediaType MediaType { get; set; }

        /// <summary>
        /// Initializes a new instance of the <see cref="MediaDirectory"/> class.
        /// </summary>
        public MediaDirectory()
        {
            MediaType = GerbilLib.MediaType.Unkown;
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="MediaDirectory"/> class.
        /// </summary>
        /// <param name="path">The path to the media directory.</param>
        public MediaDirectory(string path)
            : this()
        {
            Path = path;
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="MediaDirectory"/> class.
        /// </summary>
        /// <param name="path">The path to the media directory.</param>
        /// <param name="mediaType">Type of media in the directory.</param>
        public MediaDirectory(string path, MediaType mediaType)
            : this(path)
        {
            MediaType = mediaType;
        }

        /// <summary>
        /// Returns a <see cref="System.String"/> that represents this instance.
        /// </summary>
        /// <returns>
        /// A <see cref="System.String"/> that represents this instance.
        /// </returns>
        public override string ToString()
        {
            return string.Format("{0} ({1})", Path, MediaType);
        }
    }
}
